BEditaApiClient.save   A
last analyzed

Complexity

Conditions 5

Size

Total Lines 27
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 16
dl 0
loc 27
rs 9.1333
c 0
b 0
f 0
cc 5
1
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse }  from 'axios';
2
import AuthInterceptor from './interceptors/auth-interceptor';
3
import RefreshAuthInterceptor from './interceptors/refresh-auth-interceptor';
4
import StorageService from './services/storage-service';
5
import FormatUserInterceptor from './interceptors/format-user.interceptor';
6
import ContentTypeInterceptor from './interceptors/content-type-interceptor';
7
import { RequestInterceptorInterface } from './interceptors/request-interceptor';
8
import { ResponseInterceptorInterface } from './interceptors/response-interceptor';
9
import { MapIncludedInterceptor } from './interceptors/map-included-interceptor';
10
import StorageAdapterInterface from './services/adapters/storage-adapter-interface';
11
import LocalStorageAdapter from './services/adapters/local-storage-adapter';
12
13
/**
14
 * Interface for API client configuration.
15
 *
16
 * - baseUrl: the BEdita API base URL
17
 * - apiKey: the API KEY to use (optional). Deprecated, you are encouraged to use `clientId` and `clientSecret` instead.
18
 * - name: the name of the client instance (optional, default 'bedita')
19
 * - clientId: the client id used for client credentials flow (optional)
20
 * - clientSecret: the client secret used for client credentials flow (optional)
21
 * - storageAdapter: the adapter used by storage service
22
 */
23
export interface ApiClientConfig {
24
    baseUrl: string,
25
    apiKey?: string,
26
    name?: string,
27
    clientId?: string,
28
    clientSecret?: string,
29
    storageAdapter?: StorageAdapterInterface,
30
}
31
32
/**
33
 * Interface of JSON API resource object
34
 *
35
 * see https://jsonapi.org/format/#document-resource-objects
36
 */
37
export interface JsonApiResourceObject {
38
    type: string,
39
    id?: string,
40
    attributes?: { [s: string]: any },
41
    relationships?:  { [s: string]: any },
42
    links?: { [s: string]: any },
43
    meta?:  { [s: string]: any },
44
}
45
46
/**
47
 * Interface for a successfully API response body.
48
 */
49
export interface ApiResponseBodyOk {
50
    data: JsonApiResourceObject | JsonApiResourceObject[],
51
    meta: { [s: string]: any },
52
    links?: { [s: string]: any },
53
    included?: JsonApiResourceObject[],
54
}
55
56
/**
57
 * Interface for a errored API response body.
58
 */
59
export interface ApiResponseBodyError {
60
    error: { [s: string]: any },
61
    links?: { [s: string]: any },
62
    meta?: { [s: string]: any },
63
}
64
65
/**
66
 * Interface for configuration used for BEdita API requests.
67
 * Extends AxiosRequestConfig adding configuration for
68
 * dynamic uses of request and response interceptors.
69
 */
70
export interface BEditaClientRequestConfig extends AxiosRequestConfig {
71
    requestInterceptors?: RequestInterceptorInterface[],
72
    responseInterceptors?: ResponseInterceptorInterface[],
73
}
74
75
/**
76
 * Interface of BEdita client response.
77
 * It extends AxiosResponse adding an optional `formatData`
78
 * that can be used to store fromatted data.
79
 */
80
export interface BEditaClientResponse<T = any> extends AxiosResponse {
81
    formattedData?: T;
82
}
83
84
/**
85
 * String enums for grant types.
86
 */
87
export enum GrantType {
88
    Password = 'password',
89
    ClientCredentials = 'client_credentials',
90
    RefreshToken = 'refresh_token',
91
}
92
93
/**
94
 * String enums for upload resource types.
95
 */
96
export enum UploadResourceType {
97
    streams = 'streams',
98
    images = 'images',
99
    audio = 'audio',
100
    video = 'videos',
101
    files = 'files',
102
}
103
104
/**
105
 * Interface describing data used for auth action.
106
 */
107
export interface AuthData {
108
    username?: string,
109
    password?: string,
110
    client_id?: string,
111
    client_secret?: string,
112
    [s: string]: any,
113
    grant_type: GrantType | string,
114
}
115
116
/**
117
 * BEdita API client.
118
 */
119
export class BEditaApiClient {
120
121
    /**
122
     * The Api client configuration.
123
     */
124
    #config: ApiClientConfig;
125
126
    /**
127
     * Keep The axios instance.
128
     */
129
    #axiosInstance: AxiosInstance;
130
131
    /**
132
     * Keep the token service instance.
133
     */
134
    #storageService: StorageService;
135
136
    /**
137
     * Map of request interceptors added to avoid double addition.
138
     *
139
     * The values are the interceptor contructor names
140
     * and the keys are the corresponding index in Axios.
141
     */
142
    #requestInterceptorsMap: Map<string, number> = new Map();
143
144
    /**
145
     * Map of response interceptors added to avoid double addition.
146
     *
147
     * The values are the interceptor contructor names
148
     * and the keys are the corresponding index in Axios.
149
     */
150
    #responseInterceptorsMap: Map<string, number> = new Map();
151
152
    /**
153
     * Constructor.
154
     *
155
     * @param config The configuration for the API client
156
     */
157
    constructor(config: ApiClientConfig) {
158
        if (!config.name) {
159
            config.name = 'bedita';
160
        }
161
162
        const axiosConfig: AxiosRequestConfig = {
163
            baseURL: config.baseUrl,
164
            headers: {
165
                Accept: 'application/vnd.api+json',
166
            },
167
        };
168
169
        if (config.clientId) {
170
            delete config.apiKey; // remove deprecated API key
171
        }
172
173
        if (config.apiKey) {
174
            axiosConfig.headers['X-Api-Key'] = config.apiKey;
175
        }
176
177
        this.#config = { ...config };
178
        this.#axiosInstance = axios.create(axiosConfig);
179
        const storageAdapter = config?.storageAdapter || new LocalStorageAdapter();
180
        this.#storageService = new StorageService(config.name, storageAdapter);
181
182
        this.addDefaultInterceptors();
183
    }
184
185
    /**
186
     * Return the client configuration.
187
     * If key is specified return only the value related.
188
     */
189
    public getConfig(key?: string): ApiClientConfig | any {
190
        if (key) {
191
            return this.#config?.[key] || null;
192
        }
193
194
        return this.#config;
195
    }
196
197
    /**
198
     * Add default interceptors.
199
     */
200
    protected addDefaultInterceptors(): void {
201
        this.addInterceptor(new AuthInterceptor(this));
202
        this.addInterceptor(new ContentTypeInterceptor(this));
203
        this.addInterceptor(new RefreshAuthInterceptor(this));
204
    }
205
206
    /**
207
     * Add an interceptor to the axios instance.
208
     *
209
     * @param interceptor The interceptor instance
210
     */
211
    public addInterceptor(interceptor: RequestInterceptorInterface | ResponseInterceptorInterface): number {
212
        const name = interceptor.constructor.name;
213
        if ('requestHandler' in interceptor) {
214
            if (this.#requestInterceptorsMap.has(name)) {
215
                return this.#requestInterceptorsMap.get(name);
216
            }
217
218
            const idx = this.#axiosInstance.interceptors.request.use(
219
                interceptor.requestHandler.bind(interceptor),
220
                interceptor.errorHandler.bind(interceptor)
221
            );
222
            this.#requestInterceptorsMap.set(name, idx);
223
224
            return idx;
225
        }
226
227
        if (this.#responseInterceptorsMap.has(name)) {
228
            return this.#responseInterceptorsMap.get(name);
229
        }
230
231
        const index = this.#axiosInstance.interceptors.response.use(
232
            interceptor.responseHandler.bind(interceptor),
233
            interceptor.errorHandler.bind(interceptor)
234
        );
235
        this.#responseInterceptorsMap.set(name, index);
236
237
        return index;
238
    }
239
240
    /**
241
     * Say if interceptor is already present.
242
     *
243
     * @param interceptor The interceptor instance
244
     */
245
    public hasInterceptor(interceptor: RequestInterceptorInterface | ResponseInterceptorInterface): boolean {
246
        const name = interceptor.constructor.name;
247
        if ('requestHandler' in interceptor) {
248
            return this.#requestInterceptorsMap.has(name);
249
        }
250
251
        return this.#responseInterceptorsMap.has(name);
252
    }
253
254
    /**
255
     * Remove an interceptor from axios instance.
256
     *
257
     * @param id The interceptor id
258
     * @param type The interceptor type
259
     */
260
    public removeInterceptor(id: number, type: 'request' | 'response'): void {
261
        if (type === 'request') {
262
            for (const item of this.#requestInterceptorsMap) {
263
                if (item[1] === id) {
264
                    this.#requestInterceptorsMap.delete(item[0]);
265
                    break;
266
                }
267
            }
268
269
            return this.#axiosInstance.interceptors.request.eject(id);
270
        }
271
272
        for (const item of this.#responseInterceptorsMap) {
273
            if (item[1] === id) {
274
                this.#responseInterceptorsMap.delete(item[0]);
275
                break;
276
            }
277
        }
278
279
        this.#axiosInstance.interceptors.response.eject(id);
280
    }
281
282
    /**
283
     * Return the request interceptors map
284
     */
285
    public getRequestInterceptorsMap(): Map<string, number>
286
    {
287
        return this.#requestInterceptorsMap;
288
    }
289
290
    /**
291
     * Return the response interceptors map
292
     */
293
    public getResponseInterceptorsMap(): Map<string, number>
294
    {
295
        return this.#responseInterceptorsMap;
296
    }
297
298
    /**
299
     * Return the Axios instance.
300
     */
301
    public getHttpClient(): AxiosInstance {
302
        return this.#axiosInstance;
303
    }
304
305
    /**
306
     * Return the token service.
307
     */
308
    public getStorageService(): StorageService {
309
        return this.#storageService;
310
    }
311
312
    /**
313
     * Proxy to axios generic request.
314
     * It assure to resolve the Promise with a BEditaClientResponse.
315
     *
316
     * @param config Request configuration
317
     */
318
    public async request(config: BEditaClientRequestConfig): Promise<BEditaClientResponse<any>> {
319
        const reqIntercetorsIds = [], respInterceptorsIds = [];
320
        if (config.requestInterceptors) {
321
            config.requestInterceptors.forEach(interceptorInstance => {
322
                if (!this.hasInterceptor(interceptorInstance)) {
323
                    reqIntercetorsIds.push(this.addInterceptor(interceptorInstance));
324
                }
325
            });
326
327
            delete config.requestInterceptors;
328
        }
329
330
        if (config.responseInterceptors) {
331
            config.responseInterceptors.forEach(interceptorInstance => {
332
                if (!this.hasInterceptor(interceptorInstance)) {
333
                    respInterceptorsIds.push(this.addInterceptor(interceptorInstance));
334
                }
335
            });
336
337
            delete config.responseInterceptors;
338
        }
339
        const response = await this.#axiosInstance.request(config);
340
341
        reqIntercetorsIds.forEach(id => this.removeInterceptor(id, 'request'));
342
        respInterceptorsIds.forEach(id => this.removeInterceptor(id, 'response'));
343
344
        return response as BEditaClientResponse;
345
    }
346
347
    /**
348
     * Send a GET request.
349
     *
350
     * @param url The endpoint URL path to invoke
351
     * @param config Request configuration
352
     */
353
    public get(url: string, config?: BEditaClientRequestConfig): Promise<BEditaClientResponse<any>> {
354
        config = config || {}
355
        config.method = 'get';
356
        config.url = url;
357
358
        return this.request(config);
359
    }
360
361
    /**
362
     * Send a POST request.
363
     *
364
     * @param url The endpoint URL path to invoke
365
     * @param data Payload to send
366
     * @param config Request configuration
367
     */
368
    public post(url: string, data?: any, config?: BEditaClientRequestConfig): Promise<BEditaClientResponse<any>> {
369
        config = config || {}
370
        config.method = 'post';
371
        config.url = url;
372
        config.data = data || null;
373
374
        return this.request(config);
375
    }
376
377
    /**
378
     * Send a PATCH request.
379
     *
380
     * @param url The endpoint URL path to invoke
381
     * @param data Payload to send
382
     * @param config Request configuration
383
     */
384
    public patch(url: string, data?: any, config?: BEditaClientRequestConfig): Promise<BEditaClientResponse<any>> {
385
        config = config || {}
386
        config.method = 'patch';
387
        config.url = url;
388
        config.data = data || null;
389
390
        return this.request(config);
391
    }
392
393
    /**
394
     * Send a DELETE request.
395
     *
396
     * @param url The endpoint URL path to invoke
397
     * @param data Payload to send
398
     * @param config Request configuration
399
     */
400
    public delete(url: string, data?: any, config?: BEditaClientRequestConfig): Promise<BEditaClientResponse<any>> {
401
        config = config || {}
402
        config.method = 'delete';
403
        config.url = url;
404
        config.data = data || null;
405
406
        return this.request(config);
407
    }
408
409
    /**
410
     * Authenticate a user, saving in storage access and refresh token.
411
     *
412
     * @param username The username
413
     * @param password The password
414
     */
415
    public async authenticate(username: string, password: string): Promise<BEditaClientResponse<any>> {
416
        if (this.getConfig('apiKey')) {
417
            await this.#storageService.clearTokens();
418
        }
419
        await this.#storageService.remove('user');
420
        const data: AuthData = { username, password, grant_type: GrantType.Password };
421
422
        return await this.auth(data);
423
    }
424
425
    /**
426
     * Execute an auth request.
427
     *
428
     * @param data The auth data
429
     * @param config Additional request configuration
430
     */
431
    protected async auth(data: AuthData, config?: BEditaClientRequestConfig): Promise<BEditaClientResponse<any>> {
432
        const response = await this.post('/auth', data, config);
433
        const tokens = response.data && response.data.meta || {};
434
        if (!tokens.jwt || !tokens.renew) {
435
            return Promise.reject('Something was wrong with response data.');
436
        }
437
        await this.#storageService.setAccessToken(tokens.jwt);
438
        await this.#storageService.setRefreshToken(tokens.renew);
439
440
        return response;
441
    }
442
443
    /**
444
     * Client credentials auth.
445
     */
446
    public async clientCredentials(): Promise<BEditaClientResponse<any>> {
447
        const data: AuthData = {
448
            client_id: this.getConfig('clientId'),
449
            client_secret: this.getConfig('clientSecret'),
450
            grant_type: GrantType.ClientCredentials,
451
        };
452
453
        return await this.auth(data);
454
    }
455
456
    /**
457
     * Get the authenticated user and store it.
458
     * Format user data using `FormatUserInterceptor`.
459
     * If `include` is passed than `MapIncludedInterceptor` will be used too
460
     * to put related objects inside the relative relationship key.
461
     *
462
     * @param include A list of relationships to include
463
     */
464
    public async getUserAuth(include?: Array<string>): Promise<BEditaClientResponse<any>> {
465
        const responseInterceptors: Array<ResponseInterceptorInterface> = [new FormatUserInterceptor(this)];
466
        const params: any = {};
467
        if (include && include.length > 0) {
468
            responseInterceptors.unshift(new MapIncludedInterceptor());
469
            params.include = include.join(',');
470
        }
471
472
        const response = await this.get('/auth/user', {
473
            params,
474
            responseInterceptors,
475
        });
476
477
        await this.#storageService.set('user', JSON.stringify(response.formattedData));
478
479
        return response;
480
    }
481
482
    /**
483
     * Renew access and refresh tokens.
484
     */
485
    public async renewTokens(): Promise<BEditaClientResponse<any>> {
486
        const refreshToken = await this.#storageService.getRefreshToken();
487
        if (!refreshToken) {
488
            return Promise.reject('Missing refresh token.');
489
        }
490
491
        const config = {
492
            headers: {
493
                Authorization: `Bearer ${refreshToken}`,
494
            },
495
        };
496
497
        try {
498
            return await this.auth({ grant_type: GrantType.RefreshToken }, config);
499
        } catch (error) {
500
            await this.#storageService.clearTokens();
501
            await this.#storageService.remove('user');
502
            throw error;
503
        }
504
    }
505
506
    /**
507
     * Save a resource.
508
     * If data contains `id` then it create new one resource
509
     * else it update existing resource.
510
     *
511
     * @param type The resource type
512
     * @param data The data to save
513
     */
514
    public async save(type: string, data: {[s: string]: any}): Promise<BEditaClientResponse> {
515
        if (!type) {
516
            throw new Error('Missing required type');
517
        }
518
519
        const body: {data: JsonApiResourceObject} = { data: {type} };
520
        const id: string|null = data?.id;
521
        if (id) {
522
            body.data.id = id;
523
        }
524
        delete data.id;
525
        body.data.attributes = data;
526
527
        if (id) {
528
            return await this.patch(`${type}/${id}`, body);
529
        }
530
531
        return await this.post(`${type}`, body);
532
    }
533
534
    /**
535
     * Upload file creating the resource specified by type.
536
     * If no file name is passed then it tries to get it from File object.
537
     *
538
     * @param file File|Blob to upload
539
     * @param type The resource type (streams, images, videos, files, audio)
540
     * @param name The file name
541
     * @param config Optional request configuration
542
     */
543
    public async upload(file: File|Blob, type: UploadResourceType, name?: string, config?: BEditaClientRequestConfig): Promise<BEditaClientResponse> {
544
        name = encodeURIComponent(name || file?.name || Date.now());
545
        config = { ...{ headers: {} }, ...config };
546
        config.headers['Content-Type'] = file.type;
547
548
        return await this.post(`/${type}/upload/${name}`, await file.arrayBuffer(), config);
549
    }
550
}
551